DatabaseProgramming: blinkbuy_phase4 (2).sql

File blinkbuy_phase4 (2).sql, 37.5 KB (added by 231169, 4 days ago)
Line 
1-- ============================================================
2-- 1. fn_create_order
3-- Креира CART order и враќа order_id.
4CREATE OR REPLACE FUNCTION fn_create_order(
5 p_user_id BIGINT
6)
7RETURNS BIGINT
8LANGUAGE plpgsql
9AS $$
10DECLARE
11 v_order_id BIGINT;
12BEGIN
13 IF NOT EXISTS (
14 SELECT 1
15 FROM users u
16 JOIN user_roles ur ON ur.user_id = u.user_id
17 JOIN roles r ON r.role_id = ur.role_id
18 WHERE u.user_id = p_user_id
19 AND u.status = 'ACTIVE'
20 AND r.role_name = 'CUSTOMER'
21 ) THEN
22 RAISE EXCEPTION 'User % does not exist, is not ACTIVE, or is not CUSTOMER.', p_user_id;
23 END IF;
24
25 IF EXISTS (
26 SELECT 1
27 FROM orders o
28 WHERE o.user_id = p_user_id
29 AND o.status = 'CART'
30 ) THEN
31 RAISE EXCEPTION 'User % already has an active CART order.', p_user_id;
32 END IF;
33
34 INSERT INTO orders (
35 user_id,
36 shipping_method_id,
37 order_date,
38 total_amount,
39 status
40 )
41 VALUES (
42 p_user_id,
43 NULL,
44 NULL,
45 NULL,
46 'CART'
47 )
48 RETURNING order_id INTO v_order_id;
49
50 RETURN v_order_id;
51END;
52$$;
53
54
55-- ============================================================
56-- 2. pr_add_order_item
57-- Додава или зголемува item во customer cart.
58CREATE OR REPLACE PROCEDURE pr_add_order_item(
59 p_user_id BIGINT,
60 p_variant_id BIGINT,
61 p_quantity INT
62)
63LANGUAGE plpgsql
64AS $$
65DECLARE
66 v_order_id BIGINT;
67 v_current_price NUMERIC(12, 2);
68 v_stock_total INT;
69 v_existing_cart_quantity INT;
70 v_price_history_id BIGINT;
71BEGIN
72 IF p_quantity IS NULL OR p_quantity <= 0 THEN
73 RAISE EXCEPTION 'Quantity must be greater than 0.';
74 END IF;
75
76 IF NOT EXISTS (
77 SELECT 1
78 FROM users u
79 JOIN user_roles ur ON ur.user_id = u.user_id
80 JOIN roles r ON r.role_id = ur.role_id
81 WHERE u.user_id = p_user_id
82 AND u.status = 'ACTIVE'
83 AND r.role_name = 'CUSTOMER'
84 ) THEN
85 RAISE EXCEPTION 'User % does not exist, is not ACTIVE, or is not CUSTOMER.', p_user_id;
86 END IF;
87
88 SELECT
89 pv.price,
90 pv.stock_total
91 INTO
92 v_current_price,
93 v_stock_total
94 FROM product_variants pv
95 WHERE pv.variant_id = p_variant_id;
96
97 IF v_current_price IS NULL THEN
98 RAISE EXCEPTION 'Product variant % does not exist.', p_variant_id;
99 END IF;
100
101 SELECT o.order_id
102 INTO v_order_id
103 FROM orders o
104 WHERE o.user_id = p_user_id
105 AND o.status = 'CART'
106 ORDER BY o.order_id DESC
107 LIMIT 1;
108
109 IF v_order_id IS NULL THEN
110 v_order_id := fn_create_order(p_user_id);
111 END IF;
112
113 SELECT COALESCE(SUM(oi.quantity), 0)
114 INTO v_existing_cart_quantity
115 FROM order_items oi
116 WHERE oi.order_id = v_order_id
117 AND oi.variant_id = p_variant_id;
118
119 IF v_existing_cart_quantity + p_quantity > v_stock_total THEN
120 RAISE EXCEPTION
121 'Not enough stock for variant %. Requested total %, available %.',
122 p_variant_id,
123 v_existing_cart_quantity + p_quantity,
124 v_stock_total;
125 END IF;
126
127 SELECT pph.history_id
128 INTO v_price_history_id
129 FROM product_price_history pph
130 WHERE pph.variant_id = p_variant_id
131 ORDER BY pph.history_id DESC
132 LIMIT 1;
133
134 IF v_price_history_id IS NULL THEN
135 INSERT INTO product_price_history (
136 variant_id,
137 old_price,
138 new_price,
139 change_date
140 )
141 VALUES (
142 p_variant_id,
143 v_current_price,
144 v_current_price,
145 NOW()
146 )
147 RETURNING history_id INTO v_price_history_id;
148 END IF;
149
150 IF EXISTS (
151 SELECT 1
152 FROM order_items oi
153 WHERE oi.order_id = v_order_id
154 AND oi.variant_id = p_variant_id
155 ) THEN
156 UPDATE order_items
157 SET quantity = quantity + p_quantity,
158 unit_price = v_current_price,
159 price_history_id = v_price_history_id
160 WHERE order_item_id = (
161 SELECT MIN(oi.order_item_id)
162 FROM order_items oi
163 WHERE oi.order_id = v_order_id
164 AND oi.variant_id = p_variant_id
165 );
166 ELSE
167 INSERT INTO order_items (
168 order_id,
169 price_history_id,
170 variant_id,
171 quantity,
172 unit_price
173 )
174 VALUES (
175 v_order_id,
176 v_price_history_id,
177 p_variant_id,
178 p_quantity,
179 v_current_price
180 );
181 END IF;
182END;
183$$;
184
185
186-- ============================================================
187-- 3. pr_record_loyalty_transaction
188-- Запишува loyalty трансакција и ажурира balance.
189CREATE OR REPLACE PROCEDURE pr_record_loyalty_transaction(
190 p_loyalty_account_id BIGINT,
191 p_order_id BIGINT,
192 p_payment_id BIGINT,
193 p_points_change INT,
194 p_transaction_type VARCHAR,
195 p_description TEXT
196)
197LANGUAGE plpgsql
198AS $$
199BEGIN
200 IF p_loyalty_account_id IS NULL OR p_points_change = 0 THEN
201 RETURN;
202 END IF;
203
204 UPDATE loyalty_accounts
205 SET current_balance = GREATEST(current_balance + p_points_change, 0),
206 last_updated = NOW()
207 WHERE loyalty_account_id = p_loyalty_account_id;
208
209 INSERT INTO loyalty_history (
210 loyalty_account_id,
211 order_id,
212 payment_id,
213 points_change,
214 transaction_type,
215 description,
216 created_at
217 )
218 VALUES (
219 p_loyalty_account_id,
220 p_order_id,
221 p_payment_id,
222 p_points_change,
223 p_transaction_type,
224 p_description,
225 NOW()
226 );
227END;
228$$;
229
230
231-- ============================================================
232-- 4. pr_recalculate_variant_stock
233-- Го усогласува stock-от според physical product_instances.
234CREATE OR REPLACE PROCEDURE pr_recalculate_variant_stock(
235 p_variant_ids BIGINT[]
236)
237LANGUAGE plpgsql
238AS $$
239BEGIN
240 IF p_variant_ids IS NULL OR cardinality(p_variant_ids) = 0 THEN
241 RETURN;
242 END IF;
243
244 WITH affected_inventory AS (
245 SELECT
246 ii.variant_id,
247 ii.warehouse_id
248 FROM inventory_items ii
249 WHERE ii.variant_id = ANY(p_variant_ids)
250 ),
251 counts AS (
252 SELECT
253 ai.variant_id,
254 ai.warehouse_id,
255 COALESCE(COUNT(pi.instance_id), 0)::int AS available_quantity
256 FROM affected_inventory ai
257 LEFT JOIN product_instances pi
258 ON pi.variant_id = ai.variant_id
259 AND pi.warehouse_id = ai.warehouse_id
260 AND pi.status = 'AVAILABLE'
261 GROUP BY ai.variant_id, ai.warehouse_id
262 )
263 UPDATE inventory_items ii
264 SET quantity = c.available_quantity
265 FROM counts c
266 WHERE ii.variant_id = c.variant_id
267 AND ii.warehouse_id = c.warehouse_id;
268
269 WITH totals AS (
270 SELECT
271 ii.variant_id,
272 COALESCE(SUM(ii.quantity), 0)::int AS total_stock
273 FROM inventory_items ii
274 WHERE ii.variant_id = ANY(p_variant_ids)
275 GROUP BY ii.variant_id
276 )
277 UPDATE product_variants pv
278 SET stock_total = t.total_stock
279 FROM totals t
280 WHERE pv.variant_id = t.variant_id;
281END;
282$$;
283
284
285-- ============================================================
286-- 5. pr_pay_order
287-- Извршува payment и loyalty пресметка за PLACED order.
288CREATE OR REPLACE PROCEDURE pr_pay_order(
289 p_order_id BIGINT,
290 p_payment_method VARCHAR
291)
292LANGUAGE plpgsql
293AS $$
294DECLARE
295 v_user_id BIGINT;
296 v_order_status VARCHAR;
297 v_total_amount NUMERIC(12,2);
298
299 v_coupon_discount NUMERIC(12,2);
300 v_amount_after_coupon NUMERIC(12,2);
301
302 v_loyalty_account_id BIGINT;
303 v_current_balance INT;
304 v_calculated_redeem_points INT;
305 v_actual_redeem_points INT := 0;
306
307 v_payment_amount NUMERIC(12,2);
308 v_payment_id BIGINT;
309 v_payment_status VARCHAR;
310 v_earn_points INT;
311BEGIN
312 SELECT
313 user_id,
314 status,
315 total_amount
316 INTO
317 v_user_id,
318 v_order_status,
319 v_total_amount
320 FROM orders
321 WHERE order_id = p_order_id
322 FOR UPDATE;
323
324 IF NOT FOUND THEN
325 RAISE EXCEPTION 'Order % does not exist.', p_order_id;
326 END IF;
327
328 IF v_order_status <> 'PLACED' THEN
329 RAISE EXCEPTION 'Order % cannot be paid from status %.', p_order_id, v_order_status;
330 END IF;
331
332 IF v_total_amount IS NULL OR v_total_amount <= 0 THEN
333 RAISE EXCEPTION 'Order % has invalid total_amount: %.', p_order_id, v_total_amount;
334 END IF;
335
336 SELECT COALESCE(SUM(amount_saved), 0)::numeric(12,2)
337 INTO v_coupon_discount
338 FROM order_discounts
339 WHERE order_id = p_order_id;
340
341 v_amount_after_coupon := GREATEST(v_total_amount - v_coupon_discount, 0)::numeric(12,2);
342
343 IF v_amount_after_coupon <= 0 THEN
344 RAISE EXCEPTION 'Order % has invalid amount after coupon discount: %.',
345 p_order_id, v_amount_after_coupon;
346 END IF;
347
348 SELECT
349 loyalty_account_id,
350 current_balance
351 INTO
352 v_loyalty_account_id,
353 v_current_balance
354 FROM loyalty_accounts
355 WHERE user_id = v_user_id
356 FOR UPDATE;
357
358 IF v_loyalty_account_id IS NOT NULL AND COALESCE(v_current_balance, 0) > 0 THEN
359 v_calculated_redeem_points := FLOOR(v_amount_after_coupon / 500);
360
361 IF v_calculated_redeem_points >= 1 THEN
362 v_actual_redeem_points := LEAST(v_current_balance, v_calculated_redeem_points);
363 END IF;
364 END IF;
365
366 v_payment_amount := GREATEST(v_amount_after_coupon - v_actual_redeem_points, 1)::numeric(12,2);
367
368 SELECT
369 payment_id,
370 payment_status
371 INTO
372 v_payment_id,
373 v_payment_status
374 FROM payments
375 WHERE transaction_id = 'TXN-' || p_order_id || '-MAIN'
376 LIMIT 1
377 FOR UPDATE;
378
379 IF v_payment_id IS NOT NULL AND v_payment_status = 'SUCCESS' THEN
380 RAISE EXCEPTION 'Order % already has a successful main payment.', p_order_id;
381 ELSIF v_payment_id IS NOT NULL THEN
382 UPDATE payments
383 SET payment_method = p_payment_method,
384 amount = v_payment_amount,
385 payment_status = 'SUCCESS',
386 payment_date = NOW()
387 WHERE payment_id = v_payment_id;
388 ELSE
389 INSERT INTO payments (
390 order_id,
391 payment_method,
392 amount,
393 payment_status,
394 transaction_id,
395 payment_date
396 )
397 VALUES (
398 p_order_id,
399 p_payment_method,
400 v_payment_amount,
401 'SUCCESS',
402 'TXN-' || p_order_id || '-MAIN',
403 NOW()
404 )
405 RETURNING payment_id INTO v_payment_id;
406 END IF;
407
408 UPDATE orders
409 SET status = 'PAID'
410 WHERE order_id = p_order_id;
411
412 IF v_loyalty_account_id IS NULL THEN
413 INSERT INTO loyalty_accounts (
414 user_id,
415 current_balance,
416 last_updated
417 )
418 VALUES (
419 v_user_id,
420 0,
421 NOW()
422 )
423 RETURNING loyalty_account_id INTO v_loyalty_account_id;
424 END IF;
425
426 IF v_actual_redeem_points > 0 THEN
427 CALL pr_record_loyalty_transaction(
428 v_loyalty_account_id,
429 p_order_id,
430 v_payment_id,
431 -v_actual_redeem_points,
432 'REDEEM',
433 'Loyalty points redeemed for payment of order #' || p_order_id
434 );
435 END IF;
436
437 v_earn_points := FLOOR(v_payment_amount / 100);
438
439 IF v_earn_points > 0 THEN
440 CALL pr_record_loyalty_transaction(
441 v_loyalty_account_id,
442 p_order_id,
443 v_payment_id,
444 v_earn_points,
445 'EARN',
446 'Loyalty points earned from payment for order #' || p_order_id
447 );
448 END IF;
449
450 RAISE NOTICE 'Order % paid successfully. Coupon discount: %, redeemed points: %, payment amount: %, earned points: %.',
451 p_order_id, v_coupon_discount, v_actual_redeem_points, v_payment_amount, v_earn_points;
452END;
453$$;
454
455
456-- ============================================================
457-- 6. pr_cancel_order
458-- Откажува order пред да заврши physical delivery flow.
459CREATE OR REPLACE PROCEDURE pr_cancel_order(
460 p_order_id BIGINT
461)
462LANGUAGE plpgsql
463AS $$
464DECLARE
465 v_user_id BIGINT;
466 v_order_status VARCHAR;
467
468 v_payment_id BIGINT;
469 v_payment_status VARCHAR;
470
471 v_loyalty_account_id BIGINT;
472 v_earned_points INT := 0;
473 v_redeemed_points INT := 0;
474
475 v_released_instances INT := 0;
476BEGIN
477 SELECT
478 user_id,
479 status
480 INTO
481 v_user_id,
482 v_order_status
483 FROM orders
484 WHERE order_id = p_order_id
485 FOR UPDATE;
486
487 IF NOT FOUND THEN
488 RAISE EXCEPTION 'Order % does not exist.', p_order_id;
489 END IF;
490
491 IF v_order_status NOT IN ('CART', 'PLACED', 'PAID') THEN
492 RAISE EXCEPTION 'Order % cannot be cancelled from status %.', p_order_id, v_order_status;
493 END IF;
494
495 IF EXISTS (
496 SELECT 1
497 FROM shipments
498 WHERE order_id = p_order_id
499 AND status IN ('SHIPPED', 'IN_TRANSIT', 'DELIVERED', 'RETURNED')
500 ) THEN
501 RAISE EXCEPTION 'Order % cannot be cancelled because shipment is already physically shipped or returned.',
502 p_order_id;
503 END IF;
504
505 DROP TABLE IF EXISTS tmp_cancel_instances;
506
507 CREATE TEMP TABLE tmp_cancel_instances ON COMMIT DROP AS
508 SELECT DISTINCT
509 pi.instance_id,
510 pi.variant_id,
511 pi.warehouse_id
512 FROM shipments s
513 JOIN shipment_items si ON si.shipment_id = s.shipment_id
514 JOIN product_instances pi ON pi.instance_id = si.instance_id
515 WHERE s.order_id = p_order_id;
516
517 SELECT COUNT(*)
518 INTO v_released_instances
519 FROM tmp_cancel_instances;
520
521 UPDATE product_instances pi
522 SET status = 'AVAILABLE'
523 FROM tmp_cancel_instances tci
524 WHERE pi.instance_id = tci.instance_id;
525
526 DELETE FROM shipment_items si
527 USING shipments s
528 WHERE si.shipment_id = s.shipment_id
529 AND s.order_id = p_order_id;
530
531 UPDATE shipments
532 SET
533 status = 'FAILED',
534 tracking_number = COALESCE(tracking_number, 'CANCEL-TRK-' || shipment_id),
535 shipped_date = COALESCE(shipped_date, NOW()),
536 estimated_arrival = COALESCE(estimated_arrival, CURRENT_DATE),
537 delivered_date = NULL
538 WHERE order_id = p_order_id
539 AND status IN ('PENDING', 'PROCESSING');
540
541 SELECT
542 payment_id,
543 payment_status
544 INTO
545 v_payment_id,
546 v_payment_status
547 FROM payments
548 WHERE transaction_id = 'TXN-' || p_order_id || '-MAIN'
549 LIMIT 1
550 FOR UPDATE;
551
552 IF v_order_status = 'PAID' THEN
553 IF v_payment_id IS NULL OR v_payment_status <> 'SUCCESS' THEN
554 RAISE EXCEPTION 'Paid order % does not have a SUCCESS main payment to refund.', p_order_id;
555 END IF;
556
557 UPDATE payments
558 SET payment_status = 'REFUNDED',
559 payment_date = NOW()
560 WHERE payment_id = v_payment_id;
561
562 SELECT loyalty_account_id
563 INTO v_loyalty_account_id
564 FROM loyalty_accounts
565 WHERE user_id = v_user_id
566 FOR UPDATE;
567
568 IF v_loyalty_account_id IS NOT NULL THEN
569 SELECT COALESCE(SUM(points_change), 0)::int
570 INTO v_earned_points
571 FROM loyalty_history
572 WHERE order_id = p_order_id
573 AND payment_id = v_payment_id
574 AND transaction_type = 'EARN';
575
576 SELECT COALESCE(SUM(ABS(points_change)), 0)::int
577 INTO v_redeemed_points
578 FROM loyalty_history
579 WHERE order_id = p_order_id
580 AND payment_id = v_payment_id
581 AND transaction_type = 'REDEEM';
582
583 IF v_earned_points > 0 THEN
584 CALL pr_record_loyalty_transaction(
585 v_loyalty_account_id,
586 p_order_id,
587 v_payment_id,
588 -v_earned_points,
589 'REFUND',
590 'Loyalty points removed because order #' || p_order_id || ' was cancelled and refunded.'
591 );
592 END IF;
593
594 IF v_redeemed_points > 0 THEN
595 CALL pr_record_loyalty_transaction(
596 v_loyalty_account_id,
597 p_order_id,
598 v_payment_id,
599 v_redeemed_points,
600 'ADJUSTMENT',
601 'Redeemed loyalty points returned because order #' || p_order_id || ' was cancelled.'
602 );
603 END IF;
604 END IF;
605 ELSIF v_order_status = 'PLACED'
606 AND v_payment_id IS NOT NULL
607 AND v_payment_status = 'PENDING' THEN
608
609 UPDATE payments
610 SET payment_status = 'FAILED',
611 payment_date = NOW()
612 WHERE payment_id = v_payment_id;
613 END IF;
614
615 UPDATE orders
616 SET status = 'CANCELLED'
617 WHERE order_id = p_order_id;
618
619 IF v_released_instances > 0 THEN
620 CALL pr_recalculate_variant_stock(
621 ARRAY(
622 SELECT DISTINCT variant_id
623 FROM tmp_cancel_instances
624 )
625 );
626 END IF;
627
628 RAISE NOTICE 'Order % cancelled successfully. Released instances: %, refunded payment: %, removed earned points: %, returned redeemed points: %.',
629 p_order_id,
630 v_released_instances,
631 CASE WHEN v_order_status = 'PAID' THEN 'YES' ELSE 'NO' END,
632 v_earned_points,
633 v_redeemed_points;
634END;
635$$;
636
637
638-- ============================================================
639-- 7. pr_courier_accept_shipment
640-- Го префрла accepted shipment од PENDING во PROCESSING.
641CREATE OR REPLACE PROCEDURE pr_courier_accept_shipment(
642 p_shipment_id BIGINT
643)
644LANGUAGE plpgsql
645AS $$
646DECLARE
647 v_order_id BIGINT;
648 v_order_status VARCHAR(255);
649 v_shipment_status VARCHAR(20);
650 v_initial_courier_id BIGINT;
651 v_accepted_courier_id BIGINT;
652BEGIN
653 SELECT
654 s.order_id,
655 s.status,
656 s.courier_id,
657 o.status
658 INTO
659 v_order_id,
660 v_shipment_status,
661 v_initial_courier_id,
662 v_order_status
663 FROM shipments s
664 JOIN orders o ON o.order_id = s.order_id
665 WHERE s.shipment_id = p_shipment_id
666 FOR UPDATE OF s;
667
668 IF v_order_id IS NULL THEN
669 RAISE EXCEPTION 'Shipment % does not exist.', p_shipment_id;
670 END IF;
671
672 IF v_shipment_status <> 'PENDING' THEN
673 RAISE EXCEPTION 'Shipment % must be PENDING. Current status: %.',
674 p_shipment_id, v_shipment_status;
675 END IF;
676
677 IF v_order_status <> 'PAID' THEN
678 RAISE EXCEPTION 'Order % must be PAID before courier acceptance. Current status: %.',
679 v_order_id, v_order_status;
680 END IF;
681
682 WITH courier_candidates AS (
683 SELECT
684 u.user_id AS courier_id,
685 random() AS acceptance_roll,
686 CASE
687 WHEN u.user_id = v_initial_courier_id THEN 0
688 WHEN u.user_id > v_initial_courier_id THEN 1
689 ELSE 2
690 END AS priority_group
691 FROM users u
692 JOIN user_roles ur ON ur.user_id = u.user_id
693 JOIN roles r ON r.role_id = ur.role_id
694 WHERE r.role_name = 'COURIER'
695 AND u.status = 'ACTIVE'
696 )
697 SELECT courier_id
698 INTO v_accepted_courier_id
699 FROM courier_candidates
700 WHERE acceptance_roll < 0.80
701 ORDER BY
702 priority_group,
703 courier_id
704 LIMIT 1;
705
706 IF v_accepted_courier_id IS NULL THEN
707 RAISE EXCEPTION 'No courier accepted shipment %.', p_shipment_id;
708 END IF;
709
710 UPDATE shipments
711 SET
712 courier_id = v_accepted_courier_id,
713 status = 'PROCESSING'
714 WHERE shipment_id = p_shipment_id;
715END;
716$$;
717
718
719-- ============================================================
720-- 8. pr_process_shipment
721-- Алоцира physical product_instances и го испраќа order-от.
722CREATE OR REPLACE PROCEDURE pr_process_shipment(
723 p_shipment_id BIGINT
724)
725LANGUAGE plpgsql
726AS $$
727DECLARE
728 v_order_id BIGINT;
729 v_order_status VARCHAR;
730 v_shipping_method_id BIGINT;
731 v_shipment_status VARCHAR;
732 v_tracking_number VARCHAR;
733 v_estimated_arrival DATE;
734 v_allocated_count INT := 0;
735BEGIN
736 SELECT
737 s.order_id,
738 s.status,
739 o.status,
740 o.shipping_method_id
741 INTO
742 v_order_id,
743 v_shipment_status,
744 v_order_status,
745 v_shipping_method_id
746 FROM shipments s
747 JOIN orders o ON o.order_id = s.order_id
748 WHERE s.shipment_id = p_shipment_id
749 FOR UPDATE OF s;
750
751 IF NOT FOUND THEN
752 RAISE EXCEPTION 'Shipment % does not exist.', p_shipment_id;
753 END IF;
754
755 IF v_shipment_status <> 'PROCESSING' THEN
756 RAISE EXCEPTION 'Shipment % cannot be processed from status %.',
757 p_shipment_id, v_shipment_status;
758 END IF;
759
760 IF v_order_status <> 'PAID' THEN
761 RAISE EXCEPTION 'Shipment % cannot be processed because order % has status %.',
762 p_shipment_id, v_order_id, v_order_status;
763 END IF;
764
765 IF v_shipping_method_id IS NULL THEN
766 RAISE EXCEPTION 'Order % has no shipping_method_id.', v_order_id;
767 END IF;
768
769 DROP TABLE IF EXISTS tmp_needed_units;
770 DROP TABLE IF EXISTS tmp_available_instances;
771 DROP TABLE IF EXISTS tmp_matched_instances;
772
773 CREATE TEMP TABLE tmp_needed_units ON COMMIT DROP AS
774 WITH shipped_counts AS (
775 SELECT
776 order_item_id,
777 COUNT(*) AS shipped_count
778 FROM shipment_items
779 GROUP BY order_item_id
780 ),
781 expanded_units AS (
782 SELECT
783 oi.order_item_id,
784 oi.variant_id,
785 gs.unit_no
786 FROM order_items oi
787 LEFT JOIN shipped_counts sc ON sc.order_item_id = oi.order_item_id
788 CROSS JOIN LATERAL generate_series(
789 1,
790 GREATEST(oi.quantity - COALESCE(sc.shipped_count, 0), 0)
791 ) AS gs(unit_no)
792 WHERE oi.order_id = v_order_id
793 )
794 SELECT
795 order_item_id,
796 variant_id,
797 ROW_NUMBER() OVER (
798 PARTITION BY variant_id
799 ORDER BY order_item_id, unit_no
800 ) AS variant_unit_rn
801 FROM expanded_units;
802
803 IF NOT EXISTS (SELECT 1 FROM tmp_needed_units) THEN
804 RAISE EXCEPTION 'Shipment % has no remaining order items to process.', p_shipment_id;
805 END IF;
806
807 CREATE TEMP TABLE tmp_available_instances ON COMMIT DROP AS
808 SELECT
809 pi.instance_id,
810 pi.variant_id,
811 pi.warehouse_id,
812 ROW_NUMBER() OVER (
813 PARTITION BY pi.variant_id
814 ORDER BY pi.instance_id
815 ) AS variant_unit_rn
816 FROM product_instances pi
817 WHERE pi.status = 'AVAILABLE'
818 AND EXISTS (
819 SELECT 1
820 FROM tmp_needed_units nu
821 WHERE nu.variant_id = pi.variant_id
822 )
823 AND NOT EXISTS (
824 SELECT 1
825 FROM shipment_items si
826 WHERE si.instance_id = pi.instance_id
827 );
828
829 CREATE TEMP TABLE tmp_matched_instances ON COMMIT DROP AS
830 SELECT
831 nu.order_item_id,
832 ai.instance_id,
833 ai.variant_id,
834 ai.warehouse_id
835 FROM tmp_needed_units nu
836 JOIN tmp_available_instances ai
837 ON ai.variant_id = nu.variant_id
838 AND ai.variant_unit_rn = nu.variant_unit_rn;
839
840 IF (SELECT COUNT(*) FROM tmp_matched_instances) < (SELECT COUNT(*) FROM tmp_needed_units) THEN
841 RAISE EXCEPTION 'Not enough available product instances to fully process shipment %.', p_shipment_id;
842 END IF;
843
844 INSERT INTO shipment_items (
845 shipment_id,
846 order_item_id,
847 instance_id
848 )
849 SELECT
850 p_shipment_id,
851 order_item_id,
852 instance_id
853 FROM tmp_matched_instances;
854
855 UPDATE product_instances pi
856 SET status = 'SHIPPED'
857 FROM tmp_matched_instances mi
858 WHERE pi.instance_id = mi.instance_id;
859
860 SELECT COUNT(*)
861 INTO v_allocated_count
862 FROM tmp_matched_instances;
863
864 v_tracking_number := 'TRK-' || p_shipment_id || '-' || EXTRACT(EPOCH FROM NOW())::BIGINT;
865
866 SELECT CURRENT_DATE + sm.estimated_days
867 INTO v_estimated_arrival
868 FROM shipping_methods sm
869 WHERE sm.method_id = v_shipping_method_id;
870
871 IF v_estimated_arrival IS NULL THEN
872 RAISE EXCEPTION 'Shipping method % does not exist.', v_shipping_method_id;
873 END IF;
874
875 UPDATE shipments
876 SET
877 tracking_number = v_tracking_number,
878 shipped_date = NOW(),
879 estimated_arrival = v_estimated_arrival,
880 delivered_date = NULL,
881 status = 'SHIPPED'
882 WHERE shipment_id = p_shipment_id;
883
884 UPDATE orders
885 SET status = 'SHIPPED'
886 WHERE order_id = v_order_id;
887
888 CALL pr_recalculate_variant_stock(
889 ARRAY(
890 SELECT DISTINCT variant_id
891 FROM tmp_matched_instances
892 )
893 );
894
895 RAISE NOTICE 'Shipment % processed successfully. Allocated % physical instances. Shipment and order are now SHIPPED.',
896 p_shipment_id, v_allocated_count;
897END;
898$$;
899
900
901-- ============================================================
902-- 9. fn_is_order_returnable
903-- Проверува дали order ги исполнува return условите.
904CREATE OR REPLACE FUNCTION fn_is_order_returnable(
905 p_order_id BIGINT
906)
907RETURNS BOOLEAN
908LANGUAGE plpgsql
909AS $$
910BEGIN
911 RETURN EXISTS (
912 SELECT 1
913 FROM orders o
914 WHERE o.order_id = p_order_id
915 AND o.status = 'COMPLETED'
916 AND EXISTS (
917 SELECT 1
918 FROM shipments s
919 WHERE s.order_id = o.order_id
920 AND s.status = 'DELIVERED'
921 )
922 AND EXISTS (
923 SELECT 1
924 FROM shipments s
925 JOIN shipment_items si ON si.shipment_id = s.shipment_id
926 WHERE s.order_id = o.order_id
927 )
928 AND EXISTS (
929 SELECT 1
930 FROM payments p
931 WHERE p.order_id = o.order_id
932 AND p.transaction_id = 'TXN-' || o.order_id || '-MAIN'
933 AND p.payment_status = 'SUCCESS'
934 )
935 );
936END;
937$$;
938
939
940-- ============================================================
941-- 10. pr_process_return
942-- Процесира return и refund за completed order.
943CREATE OR REPLACE PROCEDURE pr_process_return(
944 p_order_id BIGINT,
945 p_restock BOOLEAN DEFAULT TRUE
946)
947LANGUAGE plpgsql
948AS $$
949DECLARE
950 v_user_id BIGINT;
951 v_payment_id BIGINT;
952 v_loyalty_account_id BIGINT;
953 v_earned_points INT := 0;
954 v_redeemed_points INT := 0;
955 v_returned_instances INT := 0;
956BEGIN
957 IF fn_is_order_returnable(p_order_id) = FALSE THEN
958 RAISE EXCEPTION 'Order % is not returnable. It must be COMPLETED, delivered, physically shipped, and successfully paid.',
959 p_order_id;
960 END IF;
961
962 SELECT user_id
963 INTO v_user_id
964 FROM orders
965 WHERE order_id = p_order_id
966 FOR UPDATE;
967
968 SELECT payment_id
969 INTO v_payment_id
970 FROM payments
971 WHERE order_id = p_order_id
972 AND transaction_id = 'TXN-' || p_order_id || '-MAIN'
973 AND payment_status = 'SUCCESS'
974 FOR UPDATE;
975
976 DROP TABLE IF EXISTS tmp_return_instances;
977
978 CREATE TEMP TABLE tmp_return_instances ON COMMIT DROP AS
979 SELECT DISTINCT
980 pi.instance_id,
981 pi.variant_id,
982 pi.warehouse_id
983 FROM shipments s
984 JOIN shipment_items si ON si.shipment_id = s.shipment_id
985 JOIN product_instances pi ON pi.instance_id = si.instance_id
986 WHERE s.order_id = p_order_id
987 AND s.status = 'DELIVERED';
988
989 SELECT COUNT(*)
990 INTO v_returned_instances
991 FROM tmp_return_instances;
992
993 IF v_returned_instances = 0 THEN
994 RAISE EXCEPTION 'Order % has no physical product instances to return.', p_order_id;
995 END IF;
996
997 UPDATE shipments
998 SET status = 'RETURNED'
999 WHERE order_id = p_order_id
1000 AND status = 'DELIVERED';
1001
1002 UPDATE payments
1003 SET payment_status = 'REFUNDED',
1004 payment_date = NOW()
1005 WHERE payment_id = v_payment_id;
1006
1007 SELECT loyalty_account_id
1008 INTO v_loyalty_account_id
1009 FROM loyalty_accounts
1010 WHERE user_id = v_user_id
1011 FOR UPDATE;
1012
1013 IF v_loyalty_account_id IS NOT NULL THEN
1014 SELECT COALESCE(SUM(points_change), 0)::int
1015 INTO v_earned_points
1016 FROM loyalty_history
1017 WHERE order_id = p_order_id
1018 AND payment_id = v_payment_id
1019 AND transaction_type = 'EARN';
1020
1021 SELECT COALESCE(SUM(ABS(points_change)), 0)::int
1022 INTO v_redeemed_points
1023 FROM loyalty_history
1024 WHERE order_id = p_order_id
1025 AND payment_id = v_payment_id
1026 AND transaction_type = 'REDEEM';
1027
1028 IF v_earned_points > 0 THEN
1029 CALL pr_record_loyalty_transaction(
1030 v_loyalty_account_id,
1031 p_order_id,
1032 v_payment_id,
1033 -v_earned_points,
1034 'REFUND',
1035 'Loyalty points removed because order #' || p_order_id || ' was returned and refunded.'
1036 );
1037 END IF;
1038
1039 IF v_redeemed_points > 0 THEN
1040 CALL pr_record_loyalty_transaction(
1041 v_loyalty_account_id,
1042 p_order_id,
1043 v_payment_id,
1044 v_redeemed_points,
1045 'ADJUSTMENT',
1046 'Redeemed loyalty points returned because order #' || p_order_id || ' was returned.'
1047 );
1048 END IF;
1049 END IF;
1050
1051 UPDATE product_instances pi
1052 SET status = CASE
1053 WHEN p_restock = TRUE THEN 'AVAILABLE'
1054 ELSE 'RETURNED'
1055 END
1056 FROM tmp_return_instances tri
1057 WHERE pi.instance_id = tri.instance_id;
1058
1059 IF p_restock = TRUE THEN
1060 CALL pr_recalculate_variant_stock(
1061 ARRAY(
1062 SELECT DISTINCT variant_id
1063 FROM tmp_return_instances
1064 )
1065 );
1066 END IF;
1067
1068 RAISE NOTICE 'Return processed for order %. Returned instances: %, restocked: %, refunded payment: %, removed earned points: %, returned redeemed points: %.',
1069 p_order_id,
1070 v_returned_instances,
1071 p_restock,
1072 v_payment_id,
1073 v_earned_points,
1074 v_redeemed_points;
1075END;
1076$$;
1077
1078
1079-- ============================================================
1080-- 11. trg_order_total_sync
1081-- Го синхронизира order total со order_items.
1082CREATE OR REPLACE FUNCTION fn_order_total_sync()
1083RETURNS TRIGGER
1084LANGUAGE plpgsql
1085AS $$
1086DECLARE
1087 v_old_order_id BIGINT;
1088 v_new_order_id BIGINT;
1089BEGIN
1090 IF TG_OP = 'DELETE' THEN
1091 v_old_order_id := OLD.order_id;
1092 v_new_order_id := NULL;
1093
1094 ELSIF TG_OP = 'INSERT' THEN
1095 v_old_order_id := NULL;
1096 v_new_order_id := NEW.order_id;
1097
1098 ELSE
1099 v_old_order_id := OLD.order_id;
1100 v_new_order_id := NEW.order_id;
1101 END IF;
1102
1103 IF v_old_order_id IS NOT NULL THEN
1104 UPDATE orders
1105 SET total_amount = CASE
1106 WHEN status = 'CART' THEN NULL
1107 ELSE (
1108 SELECT COALESCE(SUM(oi.quantity * oi.unit_price), 0)::numeric(12,2)
1109 FROM order_items oi
1110 WHERE oi.order_id = v_old_order_id
1111 )
1112 END
1113 WHERE order_id = v_old_order_id;
1114 END IF;
1115
1116 IF v_new_order_id IS NOT NULL
1117 AND (v_old_order_id IS NULL OR v_new_order_id <> v_old_order_id) THEN
1118
1119 UPDATE orders
1120 SET total_amount = CASE
1121 WHEN status = 'CART' THEN NULL
1122 ELSE (
1123 SELECT COALESCE(SUM(oi.quantity * oi.unit_price), 0)::numeric(12,2)
1124 FROM order_items oi
1125 WHERE oi.order_id = v_new_order_id
1126 )
1127 END
1128 WHERE order_id = v_new_order_id;
1129 END IF;
1130
1131 IF TG_OP = 'DELETE' THEN
1132 RETURN OLD;
1133 ELSE
1134 RETURN NEW;
1135 END IF;
1136END;
1137$$;
1138
1139DROP FUNCTION IF EXISTS fn_sync_single_order_total(BIGINT);
1140
1141DROP TRIGGER IF EXISTS trg_order_total_sync ON order_items;
1142
1143CREATE TRIGGER trg_order_total_sync
1144AFTER INSERT OR UPDATE OR DELETE ON order_items
1145FOR EACH ROW
1146EXECUTE FUNCTION fn_order_total_sync();
1147
1148
1149-- ============================================================
1150-- 12. trg_log_order_status_change
1151-- Автоматски запишува order status промени.
1152CREATE OR REPLACE FUNCTION fn_log_order_status_change()
1153RETURNS TRIGGER
1154LANGUAGE plpgsql
1155AS $$
1156BEGIN
1157 IF OLD.status IS DISTINCT FROM NEW.status THEN
1158
1159 INSERT INTO order_status_history (
1160 order_id,
1161 old_status,
1162 new_status,
1163 change_date
1164 )
1165 VALUES (
1166 OLD.order_id,
1167 OLD.status,
1168 NEW.status,
1169 NOW()
1170 );
1171
1172 END IF;
1173
1174 RETURN NEW;
1175END;
1176$$;
1177
1178DROP TRIGGER IF EXISTS trg_log_order_status_change ON orders;
1179
1180CREATE TRIGGER trg_log_order_status_change
1181AFTER UPDATE OF status ON orders
1182FOR EACH ROW
1183EXECUTE FUNCTION fn_log_order_status_change();
1184
1185
1186
1187-- ============================================================
1188-- 13. pr_process_inactive_user
1189-- Обработува неактивен customer account.
1190-- По 2 години неактивност додава предупредувачка нотификација.
1191-- По 3 години неактивност, ако нема активни order/shipment процеси,
1192-- ги анонимизира личните податоци и го деактивира account-от.
1193CREATE OR REPLACE PROCEDURE pr_process_inactive_user(
1194 p_user_id BIGINT,
1195 p_reference_date DATE DEFAULT CURRENT_DATE
1196)
1197LANGUAGE plpgsql
1198AS $$
1199DECLARE
1200 v_user_status VARCHAR;
1201 v_last_activity_date DATE;
1202 v_is_customer BOOLEAN;
1203 v_has_active_business_process BOOLEAN;
1204BEGIN
1205 SELECT status
1206 INTO v_user_status
1207 FROM users
1208 WHERE user_id = p_user_id
1209 FOR UPDATE;
1210
1211 IF NOT FOUND THEN
1212 RAISE EXCEPTION 'User % does not exist.', p_user_id;
1213 END IF;
1214
1215 IF v_user_status = 'DELETED' THEN
1216 RETURN;
1217 END IF;
1218
1219 SELECT EXISTS (
1220 SELECT 1
1221 FROM user_roles ur
1222 JOIN roles r ON r.role_id = ur.role_id
1223 WHERE ur.user_id = p_user_id
1224 AND r.role_name = 'CUSTOMER'
1225 )
1226 INTO v_is_customer;
1227
1228 IF NOT v_is_customer THEN
1229 RAISE EXCEPTION 'Inactive user processing is allowed only for CUSTOMER accounts. User: %', p_user_id;
1230 END IF;
1231
1232 SELECT GREATEST(
1233 COALESCE((
1234 SELECT MAX(al.login_timestamp)::date
1235 FROM auth_logs al
1236 WHERE al.user_id = p_user_id
1237 AND al.status = 'SUCCESS'
1238 ), DATE '1900-01-01'),
1239 COALESCE((
1240 SELECT MAX(us.login_time)::date
1241 FROM user_sessions us
1242 WHERE us.user_id = p_user_id
1243 ), DATE '1900-01-01'),
1244 COALESCE((
1245 SELECT MAX(o.order_date)::date
1246 FROM orders o
1247 WHERE o.user_id = p_user_id
1248 AND o.order_date IS NOT NULL
1249 ), DATE '1900-01-01'),
1250 COALESCE((
1251 SELECT u.created_at::date
1252 FROM users u
1253 WHERE u.user_id = p_user_id
1254 ), DATE '1900-01-01')
1255 )
1256 INTO v_last_activity_date;
1257
1258 IF v_last_activity_date <= p_reference_date - INTERVAL '2 years'
1259 AND NOT EXISTS (
1260 SELECT 1
1261 FROM user_notifications un
1262 WHERE un.user_id = p_user_id
1263 AND un.type = 'INACTIVITY_2_YEARS'
1264 ) THEN
1265 INSERT INTO user_notifications (
1266 user_id,
1267 type,
1268 message,
1269 sent_at
1270 )
1271 VALUES (
1272 p_user_id,
1273 'INACTIVITY_2_YEARS',
1274 'Your account has been inactive for two years and may be anonymized later.',
1275 NOW()
1276 );
1277 END IF;
1278
1279 IF v_last_activity_date > p_reference_date - INTERVAL '3 years' THEN
1280 RETURN;
1281 END IF;
1282
1283 SELECT EXISTS (
1284 SELECT 1
1285 FROM orders o
1286 WHERE o.user_id = p_user_id
1287 AND o.status IN ('CART', 'PLACED', 'PAID', 'SHIPPED')
1288 )
1289 OR EXISTS (
1290 SELECT 1
1291 FROM shipments s
1292 JOIN orders o ON o.order_id = s.order_id
1293 WHERE o.user_id = p_user_id
1294 AND s.status IN ('PENDING', 'PROCESSING', 'SHIPPED', 'IN_TRANSIT')
1295 )
1296 INTO v_has_active_business_process;
1297
1298 IF v_has_active_business_process THEN
1299 RAISE EXCEPTION 'User % cannot be anonymized because active orders or shipments exist.', p_user_id;
1300 END IF;
1301
1302 UPDATE user_sessions
1303 SET session_token = 'REVOKED-' || session_id || '-' || p_user_id,
1304 expiry_time = GREATEST(login_time + INTERVAL '1 second', NOW())
1305 WHERE user_id = p_user_id;
1306
1307 DELETE FROM wishlist_items wi
1308 USING wishlists w
1309 WHERE wi.wishlist_id = w.wishlist_id
1310 AND w.user_id = p_user_id;
1311
1312 DELETE FROM wishlists
1313 WHERE user_id = p_user_id;
1314
1315 DELETE FROM product_waitlists
1316 WHERE user_id = p_user_id;
1317
1318 DELETE FROM user_notifications
1319 WHERE user_id = p_user_id;
1320
1321 UPDATE support_tickets
1322 SET user_id = NULL
1323 WHERE user_id = p_user_id;
1324
1325 UPDATE loyalty_accounts
1326 SET current_balance = 0,
1327 last_updated = NOW()
1328 WHERE user_id = p_user_id;
1329
1330 UPDATE auth_logs
1331 SET ip_address = NULL,
1332 device_info = '[redacted]'
1333 WHERE user_id = p_user_id;
1334
1335 UPDATE addresses
1336 SET address_type = NULL,
1337 street_address = '[deleted]',
1338 city = '[deleted]',
1339 country = '[deleted]',
1340 is_default = false
1341 WHERE user_id = p_user_id;
1342
1343 UPDATE user_profiles
1344 SET first_name = 'Deleted',
1345 last_name = 'User',
1346 phone_number = NULL,
1347 profile_picture_url = NULL
1348 WHERE user_id = p_user_id;
1349
1350 UPDATE users
1351 SET email = 'deleted_user_' || p_user_id || '@deleted.local',
1352 password_hash = 'DISABLED',
1353 status = 'DELETED'
1354 WHERE user_id = p_user_id;
1355END;
1356$$;
1357
1358-- ============================================================
1359-- 14. trg_audit_price_change
1360-- Запишува price changes во price history.
1361CREATE OR REPLACE FUNCTION fn_audit_price_change()
1362RETURNS TRIGGER
1363LANGUAGE plpgsql
1364AS $$
1365BEGIN
1366 IF OLD.price IS DISTINCT FROM NEW.price THEN
1367 INSERT INTO product_price_history (
1368 variant_id,
1369 old_price,
1370 new_price,
1371 change_date
1372 )
1373 VALUES (
1374 OLD.variant_id,
1375 OLD.price,
1376 NEW.price,
1377 NOW()
1378 );
1379 END IF;
1380
1381 RETURN NEW;
1382END;
1383$$;
1384
1385DROP TRIGGER IF EXISTS trg_audit_price_change ON product_variants;
1386
1387CREATE TRIGGER trg_audit_price_change
1388AFTER UPDATE OF price ON product_variants
1389FOR EACH ROW
1390EXECUTE FUNCTION fn_audit_price_change();